Skip to content

fix(persistence): recover transient save refusals without edits - #470

Merged
chhoumann merged 3 commits into
masterfrom
fix/save-refusal-recovery
Oct 14, 2026
Merged

chhoumann merged 3 commits into
masterfrom
fix/save-refusal-recovery

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Summary

Recover automatically from runtime data.json read failures, including an idle library whose unreadable file is moved away. Imports, key selections, and visibility flushes can read through a recovered outage immediately. Older and newer schemas stop retries and show one actionable Notice. Startup failures tell users to restore access and restart, and prevent defaults from overwriting the unread file.

DataFileWriter now owns refusal policy. The existing queue, merge, lineage, write readback, and shutdown barrier remain in place. Scope is persistence wiring and regression tests.

Fixes #429
Fixes #430
Fixes #431
Fixes #432

Verification

  • Node 22.23.2. npm run lint, npm run format:check, npm run typecheck, npm run build, npm run test, final npm run test:unit (2,540 tests), npm run test:tooling (13 tests), and npm run docs:build pass.
  • Seven real Obsidian 1.14.4 journeys failed on master and pass here. They cover idle rename, both schema refusals, the import file picker, key selection, visibility flush, and startup protection. The full fresh-vault suite passes all 173 tests. The full minimum-version suite also passes all 173 tests on Obsidian 1.13.7.
  • npm run count:writes records 3 completed writes and no extra idle writes on both master and this branch. npm run size reports 98,452 gzip bytes, up 269, below the 100,000-byte limit. Raw size is 311,304 bytes, up 529.
  • Independent GPT Astra and Claude Fable reviews found no production defect. Their focused follow-up accepted the added startup-to-schema-stop regression test and its mutation proof.

Before/after evidence, disk hashes, exact commands and review record. Raw logs and review reports.

Startup before Startup after
Startup Notice before Startup Notice after
Wrong alternatives tested: all 14 fail
Mutation Failing test
Arm a checkpoint while blocked reads and writes nothing before a file was loaded, even when flushed or unloaded
Keep the old deadline after a refused read retries a refused read 10 s after the last attempt, also with edits, and writes once it reads
Exclude refusal from pending work writes the library again when its recovery write fails
Read on external callbacks after a schema stop stops for a schema it must not write: no checkpoint, read or write until the next instance
Keep an armed timer after a schema stop The same schema-stop test
Skip the external recovery write writes the library once when the file reads as missing again, even without an edit
Allow saves without a loaded baseline #432 asks for readable startup data and a reload without writing defaults
Replace the startup action with an ordinary retry message keeps refusing before a file was loaded when the file is missing, empty or unreadable
Remove the closing check before writing starts no recovery write when the window unloads during its read
Read immediately on every edit while refused The retry-deadline test
Retry newer schemas #429 stops periodic reads for schema 99 and reports the required action once
Lift startup protection on a missing-file callback The startup missing-file test
Treat invalid data as a terminal stop retries a file with an invalid schema version until it reads, then writes the library once
Keep startup wording and reads after a schema stop replaces startup protection with a schema stop and reads no further

The first timer mutation exposed a weak assertion. Moving that assertion before the strict save made the test catch it. Mutation commands and results include the failing tests for the first 13 variants. Startup-to-schema-stop mutation and failure.

QuickAdd prior art

Retains the queue, three-way merge, main-window timers, and unload protection from QuickAdd commits d418f01f, 46753e48, 40dd5b4f, and 0142db3d. QuickAdd has no refusal policy and normalizes unreadable data to defaults. PodNotes needs explicit startup protection, runtime recovery work, and schema stops to avoid overwriting its library or credentials.

Release impact

No schema, settings shape, public API, or URI change. A missing or empty file no longer unlocks an instance that started with unreadable data. A valid synced library can still be adopted safely. Runtime schema refusals require a new instance after restart or update. Shutdown still starts no new writes, so an idle recovery needs to finish before the user quits.

Docs at release

Update /save-problems/ in held PR #458:

  • State that runtime read failures retry every ten seconds even without unsaved edits. Remove the setting-change step used to force a read.
  • Remove the required setting edit after moving an unreadable file away. Wait for data.json to reappear and verify the full library before quitting.
  • For startup failure, restore readable data or read access, then reload PodNotes or restart Obsidian. Keep the note about safe adoption of a valid synced library.
  • Cover a newer-schema file that arrives mid-session, its update Notice, and stopped saves and retries.
  • Say that an import or key selection can be retried as soon as reads recover, without an unrelated edit.

Follow-up outside the permitted UI scope: remove the stale "Change any setting to save again" sentence from the import rollback failure Notice.

Note

Recover transient data-file read refusals without losing pending edits

  • Reworks DataFileWriter in writer.ts around a new ReadOutcome union. Retryable read refusals (unreadable, invalid, or missing data) now stop writes but keep retrying reads at the checkpoint interval; once a read succeeds, the writer merges, recreates a missing file, and writes pending or recovered data.
  • Schema mismatches (older or newer versions) become stop refusals: reads, timers, and writes permanently stop and the protected file stays unchanged, with a single actionable Notice.
  • PodNotes in main.ts delegates refusal decisions to the writer. Startup with an unreadable existing file now initializes in-memory defaults, refuses persistence, and directs the user to restore read access and reload. flushSaves can perform a recovery read and write instead of blanket-skipping.
  • Removes the manual retryPending API and the old four-state write-refusal type; close-time refusals remain explicit.
  • Extensive unit and end-to-end coverage added for recovery, schema stops, import/key-selection saves, missing-file recreation, and unload behavior.
  • Behavioral Change: PodNotes.saveSettingsStrict now rejects with writer-reported refusal errors; refusal Notices are replaced on new reasons and removed on recovery; external changes during an outage no longer drop the loaded library. Check persistence.Refusal, persistence.ReadOutcome, and PodNotes.dataFileAccess for the new contracts.

Macroscope summarized fa7afc0.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: fa7afc0
Status: ✅  Deploy successful!
Preview URL: https://85a52177.podnotes.pages.dev
Branch Preview URL: https://fix-save-refusal-recovery.podnotes.pages.dev

View logs

@chhoumann
chhoumann merged commit d502219 into master Oct 14, 2026
16 checks passed
@chhoumann
chhoumann deleted the fix/save-refusal-recovery branch October 14, 2026 07:04
chhoumann added a commit that referenced this pull request Oct 14, 2026
… of the full player (#472)

* refactor(feeds): drop the ignored FeedRepository cache option

Feeds are always cached since #468. The constructor still accepted a
cache option that nothing read, and eight view tests still passed it.

* refactor(player): move Next in queue into PlayerController.playNext

The headset's Next in queue read the queue store and called play inline in
bindMediaSession. PlayerController now owns it, beside play and the natural
end's queue advance.

* fix(settings): say an import that could not save yet saves by itself

Since #470, PodNotes saves on its own once it can write data.json again.
The Notice after an import whose final save failed still told the user to
change a setting to save again.

* refactor(ui): co-locate each component's styles and split Deck and Panel out of Player

PodcastView.svelte held 1,300 lines of page and component CSS in one global
block. Each component now carries its own styles, anchored on its own root
class with the `.root :global {}` pattern the shell already used. The shell
keeps its tokens, its layout, the Obsidian DOM rules and the classes several
components share.

Player.svelte loses the deck (title, scrubber, transport, capture bar) to
Deck.svelte, and ChaptersPanel and UpNextPanel share their chrome through
Panel.svelte. Cover draws a local file's tile, and SearchField takes the
first run's large variant, so no page reaches into them for it.

Variants of the shared .pn-iconbtn and .pn-btn classes win by specificity
instead of by stylesheet order.

* test(player): format the playNext tests

* feat(ui): move between the wide player's tabs with arrow keys

The Chapters and Up next tabs follow the ARIA tabs pattern: Left and Right
move focus to the previous or next tab and wrap, Home and End go to the
first and last, and the focused tab shows its panel. Only the selected tab
is in the Tab order.

* fix(ui): hide the fullscreen button in fullscreen and let play respect reduced motion

In fullscreen the video frame's fullscreen button stayed visible and did
nothing. It now hides there; Escape leaves fullscreen.

The play button's 120 ms press transition ran even when the system asks for
reduced motion. It now presses without a transition then.

* refactor(verify): split screens.mjs by concern

screens.mjs grew past 1,100 lines. It keeps its usage header, the verb
dispatch and set; the instance calls, the seeded scenes and the contact sheet
move to screens/instance.mjs, screens/scenes.mjs and screens/sheet.mjs. The
code moved verbatim apart from imports, exports and the paths that moved one
directory down.

* refactor(ui): keep each button's touch cascade and move queue rows into EpisodeRow

On a touch device Obsidian adds .mobile-tap to a tapped button, and its
button:not(.clickable-icon).mobile-tap rule paints a fill. Search results,
playlist rows, chapter rows and Up next's Edit button keep the specificity
that resisted it, and the mini player's open button and the rail's add
button keep the plain scoped rules that let it through.

Queue rows are an EpisodeRow variant (queue, dragging) instead of rules
QueueList laid over EpisodeRow and Cover. Deck no longer takes `wide`: the
player passes no onchapters when the chapter name cannot open a panel.
.pn-alt moves whole into AddPodcastPage, and the shell's head and back
button rules move from the shared block to its layout.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant